web/src/routes/hosts/[id]/+page.svelte
Ref: Size: 6.7 KiB History
<script lang="ts">
import { page } from '$app/state';
import {
fleet,
action,
decommissionHost,
upgradeAgent,
upgradeStuck,
upgradeStuckNote,
vmsForHost,
vmPhase,
vmPower,
vmIP,
hostStatusLabel,
formatUptime
} from '$lib/fleet.svelte';
import ResourceBar from '$lib/ResourceBar.svelte';
const id = $derived(page.params.id);
const host = $derived(fleet.hosts.find((h) => h.id === id));
const vms = $derived(id ? vmsForHost(id) : []);
async function decommission() {
if (!host) return;
if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return;
const hostId = host.id;
await action(() => decommissionHost(hostId));
}
async function upgrade() {
if (!host) return;
if (!confirm(`Upgrade ${host.name} to ${fleet.latest_version}?`)) return;
await action(() => upgradeAgent(host.id));
}
</script>
<p><a href="/">← fleet</a></p>
{#if !host}
<p class="hint">Host not found (it may have been decommissioned).</p>
{:else}
<h2>{host.name}</h2>
<div class="columns">
<div class="facts">
<div class="scroll">
<table class="kv">
<tbody>
<tr><th>ID</th><td>{host.id}</td></tr>
<tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{hostStatusLabel(host)}</td></tr>
<tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
<tr>
<th>Agent</th>
<td>
{host.agent_version || '—'}
<!-- One cell, three readings. An offer standing means the click
landed and the work is under way, so the button steps aside
rather than inviting a second click that changes nothing.
Once the offer is old on a host that is plainly reporting,
the same cell says so at length: the failure is on the host
and only its journal knows why. -->
{#if host.pending_upgrade}
{#if upgradeStuck(host)}
<p class="stuck">{upgradeStuckNote(host)}</p>
{:else}
<span class="pending">upgrading → {host.pending_upgrade.version}…</span>
{/if}
{:else if host.agent_update_available}
<button class="ghost upgrade" onclick={upgrade}>↑ {fleet.latest_version}</button>
{/if}
</td>
</tr>
<tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
<tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
<tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
<tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
<tr>
<th>Guest network</th>
<td>
{#if host.bridge_cidr}
{host.bridge_cidr}
{:else}
<span class="hint">not yet reported</span>
{/if}
</td>
</tr>
<tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
<tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="doing">
<div class="actions">
{#if host.status !== 'decommissioning'}
<button class="danger" onclick={decommission}>Decommission host</button>
{:else}
<span class="hint">Decommissioning—draining {vms.length} VM(s)…</span>
{/if}
</div>
<h2>Resources</h2>
<div class="resources">
<ResourceBar label="vCPU" used={host.allocated.vcpus} total={host.capacity.vcpus} />
<ResourceBar label="Memory" used={host.allocated.mem_mb} total={host.capacity.mem_mb} unit="MB" />
<ResourceBar label="Disk" used={host.allocated.disk_gb} total={host.capacity.disk_gb} unit="GB" />
</div>
<p class="note">
Allocated = sum of live VM specs. Capacity is reported by the agent (shown when online). All
three are limits at placement: a create that would put any of them over capacity is refused.
</p>
{#if host.online && host.metrics}
<h2>Host metrics (measured)</h2>
<div class="scroll">
<table class="kv">
<tbody>
<tr><th>Uptime</th><td>{formatUptime(host.metrics.uptime_s)}</td></tr>
<tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr>
<tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr>
<tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr>
</tbody>
</table>
</div>
<p class="note">
Measured off the host—distinct from the allocated bars above, which sum committed VM specs (quotas).
</p>
{/if}
</div>
</div>
<!-- The VM list is a seven-column table and takes the full measure below
both columns, as the VM page's console does. -->
<h2>VMs on this host ({vms.length})</h2>
{#if vms.length === 0}
<p class="hint">None.</p>
{:else}
<table>
<thead><tr><th>Name</th><th>vCPU</th><th>Mem</th><th>Disk</th><th>Phase</th><th>Power</th><th>IP</th></tr></thead>
<tbody>
{#each vms as v (v.id)}
<tr>
<td><a href="/vms/{v.id}">{v.name}</a></td>
<td>{v.vcpus}</td>
<td>{v.mem_mb}MB</td>
<td>{v.disk_gb}GB</td>
<td>{vmPhase(v)}</td>
<td>{vmPower(v)}</td>
<td>{vmIP(v)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<style>
h2 {
font-size: inherit;
}
/* Given the width, the page splits the way a VM's does. What this host IS
reads down the left—the identity it enrolled with and the machine it
turned out to be. What it is doing right now, and what you can do about
it, stands on the right: decommission, then how full it is and how it is
coping. The .columns grid and the .scroll boxes it holds are the shared
two-column vocabulary (:global in +layout.svelte); only this page-local
nudge stays here. */
@media (min-width: 1100px) {
/* Both columns start on the same line as the table's first rule. */
.doing .actions {
margin-top: 0.5em;
}
}
.actions {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.8em;
}
.resources {
max-width: 60ch;
}
.note {
color: var(--faint);
max-width: 72ch;
margin: 0.3em 0 1em;
}
.upgrade {
margin-left: 0.5em;
}
/* An upgrade in flight is a fact about the next few seconds, not a control:
it sits where the button was, in the faint the rest of the page uses for
things that are merely true. */
.pending {
margin-left: 0.5em;
color: var(--faint);
}
/* A stuck one is the exception—it is the only thing on this page telling an
operator that something they did failed—so it takes its own line and the
colour errors already speak in. No new colour, just the loud one. */
.stuck {
color: var(--bad);
max-width: 60ch;
margin: 0.3em 0 0;
}
</style>